Coverage Report

Created: 2026-08-07 16:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\cssh-rs\cssh-rs\xtask\src\inject_agent_token.rs
Line
Count
Source
1
//! Paseo agent GitHub auth injection.
2
//!
3
//! A paseo-spawned agent would otherwise inherit the user's full `gh`
4
//! login - including classic scopes like `repo` that allow deleting
5
//! repositories or force-pushing to `main`. This module is the
6
//! counterpart of that risk: on worktree creation, it writes a
7
//! per-worktree `.claude/settings.local.json` whose `env` map carries
8
//! a fine-grained PAT supplied by the contributor. Claude Code
9
//! injects that `env` into the agent process, and `gh` honors
10
//! `GH_TOKEN` over the keyring, so the agent ends up acting as the
11
//! scoped PAT while the contributor's own `gh` session outside paseo
12
//! is unaffected.
13
//!
14
//! The token source is `<source-checkout>/.paseo/gh-token` - a
15
//! gitignored file the contributor creates once per clone. The
16
//! source checkout path is taken from the `PASEO_SOURCE_CHECKOUT_PATH`
17
//! environment variable paseo sets when running setup steps; if that
18
//! variable is absent, the current directory is used instead, which
19
//! covers manual `cargo xtask inject-agent-token` invocations from
20
//! the repo root.
21
//!
22
//! If the token file is missing the subcommand is a silent no-op
23
//! (with an informational log line). Fine-grained PATs
24
//! (`github_pat_...`) are recommended because they can be restricted
25
//! to specific repositories and to a subset of repository permissions.
26
//! Classic (`ghp_...`) and OAuth (`gho_...`) tokens are accepted to
27
//! avoid hard-blocking contributors who only have those, but each
28
//! triggers a warning log line since they cannot be scoped tightly
29
//! enough to preserve the least-privilege property. Any other content
30
//! is rejected so we never inject arbitrary text as a token.
31
32
use std::path::{Path, PathBuf};
33
34
use anyhow::{bail, Context, Result};
35
36
/// Prefix for a fine-grained personal access token. This is the
37
/// recommended token shape because it can be restricted to specific
38
/// repositories and to a subset of repository permissions.
39
const FINE_GRAINED_PREFIX: &str = "github_pat_";
40
41
/// Prefix for a classic personal access token. Accepted to avoid
42
/// hard-blocking contributors who only have a classic token, but
43
/// flagged with a warning since classic tokens cannot be scoped to
44
/// specific repositories or to a subset of repository permissions.
45
const CLASSIC_PREFIX: &str = "ghp_";
46
47
/// Prefix for an OAuth user-to-server token. Accepted with the same
48
/// caveat as [`CLASSIC_PREFIX`].
49
const OAUTH_PREFIX: &str = "gho_";
50
51
/// Relative path inside the source checkout where the contributor
52
/// stores their GitHub token.
53
const TOKEN_FILE_REL_PATH: &str = ".paseo/gh-token";
54
55
/// Relative path inside the worktree where Claude Code reads local,
56
/// uncommitted per-project settings.
57
const SETTINGS_FILE_REL_PATH: &str = ".claude/settings.local.json";
58
59
/// All side-effecting operations performed by this subcommand.
60
///
61
/// Implement with mocks in tests to achieve zero filesystem,
62
/// environment, or process side-effects.
63
pub trait InjectAgentTokenSystem {
64
    /// Look up an environment variable.
65
    ///
66
    /// # Arguments
67
    ///
68
    /// * `key` - Environment variable name.
69
    ///
70
    /// # Returns
71
    ///
72
    /// `Some(value)` when the variable is set and non-empty,
73
    /// `None` otherwise.
74
    fn env_var(&self, key: &str) -> Option<String>;
75
76
    /// Return the current working directory.
77
    ///
78
    /// # Errors
79
    ///
80
    /// Returns an error if the current directory cannot be
81
    /// determined.
82
    fn current_dir(&self) -> Result<PathBuf>;
83
84
    /// Read the token file at `path`.
85
    ///
86
    /// # Arguments
87
    ///
88
    /// * `path` - Absolute or worktree-relative path to the token
89
    ///   file.
90
    ///
91
    /// # Returns
92
    ///
93
    /// `Ok(Some(contents))` when the file exists and is readable,
94
    /// `Ok(None)` when it does not exist (the subcommand treats
95
    /// this as a no-op).
96
    ///
97
    /// # Errors
98
    ///
99
    /// Returns an error for filesystem failures other than
100
    /// "not found" (for example, permission denied).
101
    fn read_token_file(&self, path: &Path) -> Result<Option<String>>;
102
103
    /// Write `contents` to the settings file at `path`, creating
104
    /// any missing parent directories.
105
    ///
106
    /// # Arguments
107
    ///
108
    /// * `path` - Target path for the settings file.
109
    /// * `contents` - Full file contents to write.
110
    ///
111
    /// # Errors
112
    ///
113
    /// Returns an error if directory creation or the write fails.
114
    fn write_settings(&self, path: &Path, contents: &str) -> Result<()>;
115
}
116
117
/// Production implementation of [`InjectAgentTokenSystem`].
118
pub struct RealSystem;
119
120
#[cfg_attr(coverage_nightly, coverage(off))]
121
impl InjectAgentTokenSystem for RealSystem {
122
    fn env_var(&self, key: &str) -> Option<String> {
123
        std::env::var(key).ok().filter(|v| !v.is_empty())
124
    }
125
126
    fn current_dir(&self) -> Result<PathBuf> {
127
        std::env::current_dir().context("failed to resolve current directory")
128
    }
129
130
    fn read_token_file(&self, path: &Path) -> Result<Option<String>> {
131
        match std::fs::read_to_string(path) {
132
            Ok(contents) => Ok(Some(contents)),
133
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
134
            Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
135
        }
136
    }
137
138
    fn write_settings(&self, path: &Path, contents: &str) -> Result<()> {
139
        if let Some(parent) = path.parent() {
140
            std::fs::create_dir_all(parent)
141
                .with_context(|| format!("failed to create {}", parent.display()))?;
142
        }
143
        std::fs::write(path, contents)
144
            .with_context(|| format!("failed to write {}", path.display()))?;
145
        Ok(())
146
    }
147
}
148
149
/// Build the JSON body written to `.claude/settings.local.json`.
150
///
151
/// Caller-enforced invariant: `token` contains only bytes in
152
/// `[A-Za-z0-9_]`. That alphabet has no characters that require JSON
153
/// escaping, which is what lets this function skip a general-purpose
154
/// JSON encoder without risking injection. The invariant is enforced
155
/// by [`is_in_token_alphabet`] inside [`inject_agent_token`].
156
///
157
/// # Arguments
158
///
159
/// * `token` - GitHub token, already validated and trimmed.
160
///
161
/// # Returns
162
///
163
/// A pretty-printed JSON document terminated with a newline.
164
4
fn build_settings_body(token: &str) -> String {
165
4
    format!(
166
        "{{\n  \"env\": {{\n    \"GH_TOKEN\": \"{token}\",\n    \"GH_HOST\": \"github.com\"\n  }}\n}}\n"
167
    )
168
4
}
169
170
/// Return `true` when every byte of `token` is in the GitHub token
171
/// alphabet `[A-Za-z0-9_]`.
172
///
173
/// Enforcing this invariant is what lets [`build_settings_body`]
174
/// embed the token directly into a JSON template without escaping -
175
/// none of the characters in this alphabet need JSON escaping, so a
176
/// token that passes this check cannot break out of its string
177
/// literal nor inject additional keys. Fine-grained PATs, classic
178
/// PATs, and OAuth tokens all share the same alphabet, so the same
179
/// check applies to every accepted token shape.
180
///
181
/// # Arguments
182
///
183
/// * `token` - Trimmed token to validate.
184
///
185
/// # Returns
186
///
187
/// `true` when `token` is non-empty and contains only the allowed
188
/// characters; `false` otherwise.
189
5
fn is_in_token_alphabet(token: &str) -> bool {
190
5
    !token.is_empty()
191
5
        && token
192
5
            .bytes()
193
123
            .
all5
(|b| b.is_ascii_alphanumeric() ||
b == b'_'9
)
194
5
}
195
196
/// Recognized GitHub token shapes.
197
#[derive(Clone, Copy)]
198
enum TokenKind {
199
    FineGrained,
200
    Classic,
201
    OAuth,
202
}
203
204
impl TokenKind {
205
    /// Identify the token shape from its prefix.
206
    ///
207
    /// # Arguments
208
    ///
209
    /// * `token` - Trimmed token contents.
210
    ///
211
    /// # Returns
212
    ///
213
    /// `Some(kind)` when the token starts with a recognized prefix,
214
    /// `None` otherwise.
215
6
    fn classify(token: &str) -> Option<Self> {
216
6
        if token.starts_with(FINE_GRAINED_PREFIX) {
217
3
            Some(Self::FineGrained)
218
3
        } else if token.starts_with(CLASSIC_PREFIX) {
219
1
            Some(Self::Classic)
220
2
        } else if token.starts_with(OAUTH_PREFIX) {
221
1
            Some(Self::OAuth)
222
        } else {
223
1
            None
224
        }
225
6
    }
226
}
227
228
/// Resolve the source checkout directory.
229
///
230
/// Paseo passes `PASEO_SOURCE_CHECKOUT_PATH` into `worktree.setup`
231
/// subprocesses. When the variable is missing - for example when the
232
/// subcommand is invoked manually - fall back to the current
233
/// directory so running it from the repo root behaves intuitively.
234
///
235
/// # Arguments
236
///
237
/// * `system` - Injected I/O provider.
238
///
239
/// # Returns
240
///
241
/// The source checkout path.
242
///
243
/// # Errors
244
///
245
/// Returns an error only when the fallback `current_dir` lookup
246
/// fails.
247
9
fn resolve_source_checkout<S: InjectAgentTokenSystem>(system: &S) -> Result<PathBuf> {
248
9
    if let Some(
path8
) = system.env_var("PASEO_SOURCE_CHECKOUT_PATH") {
249
8
        return Ok(PathBuf::from(path));
250
1
    }
251
1
    system.current_dir()
252
9
}
253
254
/// Inject the contributor's GitHub token into the current worktree's
255
/// Claude Code settings.
256
///
257
/// The token is read from `<source-checkout>/.paseo/gh-token`. A
258
/// missing token file is treated as an opt-out: the function logs a
259
/// notice and returns `Ok(())` so worktree creation is not blocked
260
/// for contributors who have not set a token up yet. Fine-grained
261
/// PATs are written silently; classic and OAuth tokens are written
262
/// but trigger a warning log line recommending fine-grained PATs.
263
///
264
/// # Arguments
265
///
266
/// * `system` - Injected I/O provider.
267
///
268
/// # Returns
269
///
270
/// `Ok(())` on success or when the token file is absent.
271
///
272
/// # Errors
273
///
274
/// Returns an error when a token file exists but does not start with
275
/// one of the recognized prefixes ([`FINE_GRAINED_PREFIX`],
276
/// [`CLASSIC_PREFIX`], [`OAUTH_PREFIX`]), when its trimmed contents
277
/// fall outside the token alphabet (see [`is_in_token_alphabet`]),
278
/// or when the settings file cannot be written.
279
9
pub fn inject_agent_token<S: InjectAgentTokenSystem>(system: &S) -> Result<()> {
280
9
    let source = resolve_source_checkout(system)
?0
;
281
9
    let token_file = source.join(TOKEN_FILE_REL_PATH);
282
283
9
    let Some(
raw7
) = system.read_token_file(&token_file)
?0
else {
284
2
        log::info!(
285
            "paseo agent GitHub auth: no {} found; agents will use your existing gh login. See CONTRIBUTING.md.",
286
2
            token_file.display()
287
        );
288
2
        return Ok(());
289
    };
290
291
7
    let token = raw.trim();
292
7
    if token.is_empty() {
293
1
        bail!(
294
            "{} is empty; expected a GitHub token starting with `{}` (recommended), `{}`, or `{}`. See CONTRIBUTING.md.",
295
1
            token_file.display(),
296
            FINE_GRAINED_PREFIX,
297
            CLASSIC_PREFIX,
298
            OAUTH_PREFIX,
299
        );
300
6
    }
301
6
    let Some(
kind5
) = TokenKind::classify(token) else {
302
1
        bail!(
303
            "{} must contain a GitHub token starting with `{}` (recommended), `{}`, or `{}`. See CONTRIBUTING.md.",
304
1
            token_file.display(),
305
            FINE_GRAINED_PREFIX,
306
            CLASSIC_PREFIX,
307
            OAUTH_PREFIX,
308
        );
309
    };
310
5
    if !is_in_token_alphabet(token) {
311
1
        bail!(
312
            "{} contains characters outside the GitHub token alphabet ([A-Za-z0-9_]); refusing to embed it in settings. See CONTRIBUTING.md.",
313
1
            token_file.display()
314
        );
315
4
    }
316
317
4
    let cwd = system.current_dir()
?0
;
318
4
    let settings_path = cwd.join(SETTINGS_FILE_REL_PATH);
319
4
    let body = build_settings_body(token);
320
4
    system.write_settings(&settings_path, &body)
?0
;
321
322
4
    match kind {
323
        TokenKind::FineGrained => {
324
2
            log::info!(
325
                "paseo agent GitHub auth: wrote {} from {} (scoped PAT)",
326
2
                settings_path.display(),
327
2
                token_file.display(),
328
            );
329
        }
330
        TokenKind::Classic => {
331
1
            log::warn!(
332
                "paseo agent GitHub auth: detected a classic token in {}; wrote {} but fine-grained PATs (prefix `{}`) are recommended because they can be restricted to specific repositories and permissions, while classic tokens cannot. See CONTRIBUTING.md.",
333
1
                token_file.display(),
334
1
                settings_path.display(),
335
                FINE_GRAINED_PREFIX,
336
            );
337
        }
338
        TokenKind::OAuth => {
339
1
            log::warn!(
340
                "paseo agent GitHub auth: detected an OAuth token in {}; wrote {} but fine-grained PATs (prefix `{}`) are recommended because they can be restricted to specific repositories and permissions, while OAuth tokens cannot. See CONTRIBUTING.md.",
341
1
                token_file.display(),
342
1
                settings_path.display(),
343
                FINE_GRAINED_PREFIX,
344
            );
345
        }
346
    }
347
348
4
    Ok(())
349
9
}
350
351
#[cfg(test)]
352
#[path = "tests/test_inject_agent_token.rs"]
353
mod tests;